<?php

namespace mikehaertl\pdftk;

use mikehaertl\tmp\File;

/**
 * Pdf
 *
 * This class is a wrapper around pdftk.
 *
 * The class was developed for pdftk 2.x but should also work with older
 * versions, but you may have to use slightly different page rotation options
 * (e.g 'E' instead 'east').
 *
 * @author Michael Härtl <haertl.mike@gmail.com>
 * @license http://www.opensource.org/licenses/MIT
 */
class Pdf
{
    // The prefix for temporary files
    private const TMP_PREFIX = 'tmp_php_pdftk_';

    /**
     * Whether to ignore any errors if some non-empty output file was
     * still created. Default is false.
     */
    public bool $ignoreWarnings = false;

    /**
     * An optional directory where temporary files should be
     * created. If left empty the directory is autodetected.
     */
    public ?string $tempDir = null;

    /**
     * The temporary output file
     */
    protected ?File $_tmpFile = null;

    /**
     * The content type of the tmp output
     */
    protected string $_tmpOutputContentType = 'application/pdf';

    /**
     * The command instance that executes pdftk
     */
    protected ?Command $_command = null;

    /**
     * A counter for autogenerated handles
     */
    protected int $_handle = 0;

    /**
     * The error message
     */
    protected string $_error = '';

    /**
     * The output filename. If null (default) a tmp file is
     * used as output. If false, no output option is added at all.
     */
    protected ?string $_output = null;

    /**
     * The PDF data as returned from getData()
     */
    protected ?InfoFields $_data = null;
    protected ?InfoFields $_data_utf8 = null;

    /**
     * The PDF form field data as returned from getDataFields()
     */
    protected ?DataFields $_dataFields = null;
    protected ?DataFields $_dataFields_utf8 = null;

    /**
     * @var Pdf[] if the input was an instance, we keep a reference here,
     * so that it won't get unlinked before this object gets destroyed
     */
    protected array $_pdfs = [];

    /**
     * @param string|Pdf|array $pdf a pdf filename or Pdf instance or an array
     * of filenames/instances indexed by a handle. The array values can also
     * be arrays of the form array($filename, $password) if some files are
     * password protected.
     * @param array $options Options to pass to set on the Command instance,
     * e.g. the pdftk binary path
     */
    public function __construct(string|Pdf|array|null $pdf = null, array $options = array())
    {
        $command = $this->getCommand();
        if ($options !== array()) {
            $command->setOptions($options);
        }
        if (is_string($pdf) || $pdf instanceof Pdf) {
            $this->addFile($pdf);
        } elseif (is_array($pdf)) {
            foreach ($pdf as $handle => $file) {
                if (is_array($file)) {
                    $this->addFile($file[0], $handle, $file[1]);
                } else {
                    $this->addFile($file, $handle);
                }
            }
        }
    }

    /**
     * @param string|Pdf $name the PDF filename or Pdf instance to add for
     * processing
     * @param string|null $handle one or more uppercase letters A..Z to
     * reference this file later. If no handle is provided, an internal handle
     * is autocreated, consuming the range Z..A
     * @param string|null $password the owner (or user) password if any
     * @return Pdf the pdf instance for method chaining
     */
    public function addFile(string|Pdf $name, ?string $handle = null, ?string $password = null): self
    {
        if ($handle === null || is_numeric($handle)) {
            $handle = $this->nextHandle();
        }
        if ($name instanceof Pdf) {
            // Keep a reference to the object to prevent unlinking
            $this->_pdfs[] = $name;
            if (!$name->getCommand()->getExecuted()) {
                // @todo: Catch errors!
                $name->execute();
            }
            $name = (string) $name->getTmpFile();
        }
        $this->getCommand()->addFile($name, $handle, $password);
        return $this;
    }

    /**
     * Assemble (catenate) pages from the input files.
     *
     * Values for rotation are (in degrees): north: 0, east: 90, south: 180,
     * west: 270, left: -90, right: +90, down: +180. left, right and down make
     * relative adjustments to a page's rotation. Note: Older pdftk versions
     * use N, E, S, W, L, R, and D instead.
     *
     * Example:
     *
     *  $pdf = new Pdf;
     *  $pdf->addFile('file1.pdf', 'A')
     *      ->addFile('file2.pdf', 'B')
     *      ->cat(array(1,3),'B'))          // pages 1 and 3 of file B
     *      ->cat(1, 5, 'A', 'odd')         // pages 1, 3, 5 of file A
     *      ->cat('end', 5, 'B')            // pages 5 to end of file B in reverse order
     *      ->cat(null, null, 'B', 'east')  // All pages from file B rotated by 90 degree
     *      ->saveAs('out.pdf');
     * or
     *  $files = ['file1.pdf', 'file2.pdf', 'file3.pdf'];
     *  $pdf = new Pdf($files);
     *  $pdf->cat()                         // all files, all pages
     *      ->saveAs('out.pdf');
     *
     * @param int|string|array|null $start the start page number or an array of page
     * numbers. If an array, the other arguments will be ignored. $start can
     * also be bigger than $end for pages in reverse order. If $start is null all
     * pages of all files will be added.
     * @param int|string|null $end the end page number or null for single page
     * (or list if $start is an array)
     * @param string|null $handle the handle of the file to use. Can be null if
     * only a single file was added.
     * @param string|null $qualifier the page number qualifier, either 'even'
     * or 'odd' or null for none
     * @param string $rotation the rotation to apply to the pages.
     * @return Pdf the pdf instance for method chaining
     */
    public function cat(
        int|string|array|null $start = null,
        int|string|null $end = null,
        ?string $handle = null,
        ?string $qualifier = null,
        ?string $rotation = null,
    ): self {
        $this->getCommand()
            ->setOperation('cat')
            ->addPageRange(is_null($start) ? "" : $start, $end, $handle, $qualifier, $rotation);

        return $this;
    }

    /**
     * Shuffle pages from the input files.
     *
     * This works the same as cat(), but each call to this method creates a
     * "stream" of pages. The outfile will be assembled by adding one page from
     * each stream at a time.
     *
     * Example:
     *
     *  $pdf = new Pdf;
     *  $pdf1 = $pdf->addFile('file1.pdf');
     *  $pdf->shuffle($pdf1, array(1,3,2))
     *      ->shuffle($pdf1, array(4,5,9)
     *      ->saveAs('out.pdf');
     *
     * This will give the page order 1, 4, 3, 5, 2, 9 in the out.pdf
     *
     * @param int|string|array $start the start page number or an array of page
     * numbers.
     * @param int|string|null $end the end page number or null for single page (or
     * list if $start is an array)
     * @param string $handle the handle of the input file to use
     * @param string|null $qualifier the page number qualifier, either 'even'
     * or 'odd' or null for none
     * @param string $rotation the rotation to apply to the pages. See cat()
     * for more details.
     * @return Pdf the pdf instance for method chaining
     */
    public function shuffle(
        int|string|array $start,
        int|string|null $end = null,
        ?string $handle = null,
        ?string $qualifier = null,
        ?string $rotation = null,
    ): self {
        $this->getCommand()
            ->setOperation('shuffle')
            ->addPageRange($start, $end, $handle, $qualifier, $rotation);
        return $this;
    }

    /**
     * Split the PDF document into pages
     *
     * @param string|null $filepattern the output name in sprintf format or
     * null for default 'pg_%04d.pdf'
     * @return bool whether the burst operation was successful
     */
    public function burst(?string $filepattern = null): bool
    {
        $this->constrainSingleFile();
        $this->getCommand()->setOperation('burst');
        $this->_output = $filepattern === null ? 'pg_%04d.pdf' : $filepattern;
        return $this->execute();
    }

    /**
     * Attach files to the PDF
     *
     * @param array $files the list of full paths to the files to attach
     * @param string $toPage the page to add the attachment to. If omitted the
     * files are attached at the document level.
     *
     * @return self whether the operation was successful
     */
    public function attachFiles(array $files, ?string $toPage = null): self
    {
        $this->constrainSingleFile();
        if ($toPage !== null) {
            $files[] = 'to_page';
            $files[] = $toPage;
        }
        $this->getCommand()
            ->setOperation('attach_files')
            ->setOperationArgument($files, true);
        return $this;
    }

    /**
     * Copy all attachments from the PDF to the given directory
     *
     * @param string|null $dir the output directory
     * @return bool whether the operation was successful
     */
    public function unpackFiles(?string $dir = null): bool
    {
        $this->constrainSingleFile();
        $this->getCommand()->setOperation('unpack_files');
        $this->_output = $dir;
        return $this->execute();
    }

    /**
     * Generate the FDF file for a single PDF file.
     *
     * @param string $name name of the FDF file
     * @return bool whether the pdf is generated successful
     */
    public function generateFdfFile(string $name): bool
    {
        $this->constrainSingleFile();
        $this->getCommand()->setOperation('generate_fdf');
        $this->_output = $name;
        return $this->execute();
    }

    /**
     * Fill a PDF form
     *
     * @param string|array $data either a XFDF/FDF filename or an array with
     * form field data (name => value)
     * @param string $encoding the encoding of the data. Default is 'UTF-8'.
     * @param bool $dropXfa whether to drop XFA forms (see dropXfa()). Default
     * is true.
     * @param string $format the file format to use for form filling when
     * passing an array in `$data`. This can be `xfdf` or `fdf`. `xfdf` should
     * give best results so you should not have to change the default.
     * @return Pdf the pdf instance for method chaining
     */
    public function fillForm(
        string|array $data,
        string $encoding = 'UTF-8',
        bool $dropXfa = true,
        string $format = 'xfdf',
    ): self {
        $this->constrainSingleFile();
        if (is_array($data)) {
            $className = '\mikehaertl\pdftk\\' . ($format === 'xfdf' ? 'XfdfFile' : 'FdfFile');
            $data = new $className($data, null, null, $this->tempDir, $encoding);
        }
        $this->getCommand()
            ->setOperation('fill_form')
            ->setOperationArgument($data, true);

        if ($dropXfa) {
            $this->dropXfa();
        }
        return $this;
    }

    /**
     * Update meta data of PDF
     *
     * @param string|array $data either a InfoFile filename or an array with
     * form field data (name => value)
     * @param string the encoding of the data. Default is 'UTF-8'.
     * @return Pdf the pdf instance for method chaining
     */
    public function updateInfo(string|array|InfoFields|InfoFile $data, string $encoding = 'UTF-8'): self
    {
        $this->constrainSingleFile();
        if (is_array($data) || $data instanceof InfoFields) {
            $data = new InfoFile($data, null, null, $this->tempDir, $encoding);
        }
        $this->getCommand()
            ->setOperation($encoding == 'UTF-8' ? 'update_info_utf8' : 'update_info')
            ->setOperationArgument($data, true);

        return $this;
    }

    /**
     * Apply a PDF as watermark to the background of a single PDF file.
     *
     * The PDF file must have a transparent background for the watermark to be
     * visible.
     *
     * @param string $file name of the background PDF file. Only the first page
     * is used.
     * @return Pdf the pdf instance for method chaining
     */
    public function background(string $file): self
    {
        $this->constrainSingleFile();
        $this->getCommand()
            ->setOperation('background')
            ->setOperationArgument($file, true);
        return $this;
    }

    /**
     * Apply multiple PDF pages as watermark to the corresponding pages of a
     * single PDF file.
     *
     * If $file has fewer pages than the PDF file then the last page is
     * repeated as background.
     *
     * @param string $file name of the background PDF file.
     * @return Pdf the pdf instance for method chaining
     */
    public function multiBackground(string $file): self
    {
        $this->getCommand()
            ->setOperation('multibackground')
            ->setOperationArgument($file, true);
        return $this;
    }

    /**
     * Add $file as overlay to a single PDF file.
     *
     * The $file should have a transparent background.
     *
     * @param string $file name of the PDF file to add as overlay. Only the
     * first page is used.
     * @return Pdf the pdf instance for method chaining
     */
    public function stamp(string $file): self
    {
        $this->constrainSingleFile();
        $this->getCommand()
            ->setOperation('stamp')
            ->setOperationArgument($file, true);
        return $this;
    }

    /**
     * Add multiple pages from $file as overlay to the corresponding pages of a
     * single PDF file.
     *
     * If $file has fewer pages than the PDF file then the last page is
     * repeated as overlay.
     *
     * @param string $file name of the PDF file to add as overlay
     * @return Pdf the pdf instance for method chaining
     */
    public function multiStamp(string $file): self
    {
        $this->getCommand()
            ->setOperation('multistamp')
            ->setOperationArgument($file, true);
        return $this;
    }

    /**
     * @param bool $utf8 whether to dump the data UTF-8 encoded. Default is
     * true.
     * @return InfoFields|bool meta data about the PDF or false on failure
     */
    public function getData(bool $utf8 = true): InfoFields|bool
    {
        $property = $utf8 ? '_data_utf8' : '_data';
        if ($this->$property === null) {
            $command = $this->getCommand();
            $command->setOperation($utf8 ? 'dump_data_utf8' : 'dump_data');
            if (!$command->execute()) {
                $this->_error = $command->getError();
                return false;
            } else {
                $this->$property = new InfoFields(trim($command->getOutput()));
            }
        }
        return $this->$property;
    }

    /**
     * @param bool $utf8 whether to dump the data UTF-8 encoded. Default is
     * true.
     * @return DataFields|bool data about the PDF form fields or false on
     * failure
     */
    public function getDataFields(bool $utf8 = true): DataFields|bool
    {
        $property = $utf8 ? '_dataFields_utf8' : '_dataFields';
        if ($this->$property === null) {
            $command = $this->getCommand();
            $command->setOperation($utf8 ? 'dump_data_fields_utf8' : 'dump_data_fields');
            if (!$command->execute()) {
                $this->_error = $command->getError();
                return false;
            } else {
                $this->$property = new DataFields(trim($command->getOutput()));
            }
        }
        return $this->$property;
    }

    /**
     * Set PDF permissions
     *
     *
     * @param string|null $permissions list of space separated permissions or
     * null for none. The available permissions are Printing, DegradedPrinting,
     * ModifyContents, Assembly, CopyContents, ScreenReaders,
     * ModifyAnnotations, FillIn, AllFeatures.
     * @return Pdf the pdf instance for method chaining
     */
    public function allow($permissions = null): self
    {
        $this->getCommand()
            ->addOption('allow', $permissions, false);
        return $this;
    }

    /**
     * Flatten the PDF form fields values into a single PDF file.
     *
     * @return Pdf the pdf instance for method chaining
     */
    public function flatten(): self
    {
        $this->getCommand()
            ->addOption('flatten');
        return $this;
    }

    /**
     * Restore/remove compression
     *
     * @param bool $compress whether to restore (default) or remove the
     * compression
     * @return Pdf the pdf instance for method chaining
     */
    public function compress($compress = true): self
    {
        $this->getCommand()
            ->addOption($compress ? 'compress' : 'uncompress');
        return $this;
    }

    /**
     * When combining multiple PDFs, use either the first or last ID in the
     * output. If not called, a new ID is created.
     *
     * @param string $id, either 'first' (default) or 'last'
     * @return Pdf the pdf instance for method chaining
     */
    public function keepId($id = 'first'): self
    {
        $this->getCommand()
            ->addOption($id === 'first' ? 'keep_first_id' : 'keep_final_id');
        return $this;
    }

    /**
     * Set need_appearances flag in PDF
     *
     * This flag makes sure, that a PDF reader takes care of rendering form
     * field content, even if it contains non ASCII characters. You should
     * always use this option if you fill in forms e.g. with Unicode
     * characters. You can't combine this option with flatten() though!
     *
     * @return Pdf the pdf instance for method chaining
     */
    public function needAppearances(): self
    {
        $this->getCommand()
            ->addOption('need_appearances');
        return $this;
    }

    /**
     * Drop XFA data from forms created with newer Acrobat.
     *
     * Newer PDF forms contain both, the newer XFA and the older AcroForm form
     * fields. PDF readers can use both, but will prefer XFA if present. Since
     * pdftk can only fill in AcroForm data you should always add this option
     * when filling in forms with pdftk.
     *
     * @return Pdf the pdf instance for method chaining
     */
    public function dropXfa(): self
    {
        $this->getCommand()
            ->addOption('drop_xfa');
        return $this;
    }

    /**
     * Drop XMP meta data
     *
     * Newer PDFs can contain both, new style XMP data and old style info
     * directory. PDF readers can use both, but will prefer XMP if present.
     * Since pdftk can only update the info directory you should always add
     * this option when updating PDF info.
     *
     * @return Pdf the pdf instance for method chaining
     */
    public function dropXmp(): self
    {
        $this->getCommand()
            ->addOption('drop_xmp');
        return $this;
    }

    /**
     * @param string $password the owner password to set on the output PDF
     * @return Pdf the pdf instance for method chaining
     */
    public function setPassword(string $password): self
    {
        $this->getCommand()
            ->addOption('owner_pw', $password, true);
        return $this;
    }

    /**
     * @param string $password the user password to set on the output PDF
     * @return Pdf the pdf instance for method chaining
     */
    public function setUserPassword(string $password): self
    {
        $this->getCommand()
            ->addOption('user_pw', $password, true);
        return $this;
    }

    /**
     * @param int $strength the password encryption strength. Default is 128
     * @return Pdf the pdf instance for method chaining
     */
    public function passwordEncryption($strength = 128)
    {
        $this->getCommand()
            ->addOption($strength == 128 ? 'encrypt_128bit' : 'encrypt_40bit');
        return $this;
    }

    /**
     * Replace embedded font with a local font when filling a form.
     *
     * This option is only available for pdftk-java >= 3.3.0. It is useful when
     * filling a form with non-ASCII text that is not supported by the fonts
     * included in the input PDF.
     *
     * @param string $fontName the path to the font or the name of a font family.
     * @return Pdf the pdf instance for method chaining
     */
    public function replacementFont(string $path): self
    {
        $this->getCommand()
            ->addOption('replacement_font', $path);
        return $this;
    }

    /**
     * Execute the operation and save the output file
     *
     * @param string $name of output file
     * @return bool whether the PDF could be processed and saved
     */
    public function saveAs(string $name): bool
    {
        if (!$this->getCommand()->getExecuted() && !$this->execute()) {
            return false;
        }
        $tmpFile = (string) $this->getTmpFile();
        if (!copy($tmpFile, $name)) {
            $this->_error = "Could not copy PDF from tmp location '$tmpFile' to '$name'";
            return false;
        }
        return true;
    }

    /**
     * Send PDF to client, either inline or as download (triggers PDF creation)
     *
     * @param string|null $filename the filename to send. If empty, the PDF is
     * streamed inline.
     * @param bool $inline whether to force inline display of the PDF, even if
     * filename is present.
     * @param array $headers a list of additional HTTP headers to send in the
     * response as an array. The array keys are the header names like
     * 'Cache-Control' and the array values the header value strings to send.
     * Each array value can also be another array of strings if the same header
     * should be sent multiple times. This can also be used to override
     * automatically created headers like 'Expires' or 'Content-Length'. To suppress
     * automatically created headers, `false` can also be used as header value.
     * @return bool whether PDF was created successfully
     */
    public function send(?string $filename = null, bool $inline = false, array $headers = array()): bool
    {
        if (!$this->getCommand()->getExecuted() && !$this->execute()) {
            return false;
        }
        $this->getTmpFile()->send($filename, $this->_tmpOutputContentType, $inline, $headers);
        return true;
    }

    /**
     * Get the raw PDF contents (triggers PDF creation).
     *
     * @return string|bool the PDF content as a string or `false` if the PDF
     * wasn't created successfully.
     */
    public function toString(): string|bool
    {
        if (!$this->getCommand()->getExecuted() && !$this->execute()) {
            return false;
        }
        return file_get_contents($this->getTmpFile()->getFileName());
    }

    /**
     * @return Command the command instance that executes pdftk
     */
    public function getCommand(): Command
    {
        if ($this->_command === null) {
            $this->_command = new Command();
        }
        return $this->_command;
    }

    /**
     * @return File the temporary output file instance
     */
    public function getTmpFile(): File
    {
        if ($this->_tmpFile === null) {
            $this->_tmpFile = new File('', '.pdf', self::TMP_PREFIX, $this->tempDir);
        }
        return $this->_tmpFile;
    }

    /**
     * @return string the error message or an empty string if none
     */
    public function getError(): string
    {
        return $this->_error;
    }

    /**
     * Execute the pdftk command and store the output file to a temporary
     * location or $this->_output if set.  You should probably never call this
     * method unless you only need a temporary PDF file as result.
     *
     * @return bool whether the command was executed successfully
     */
    public function execute(): bool
    {
        $command = $this->getCommand();
        if ($command->getExecuted()) {
            return false;
        }

        if ($this->_output === false) {
            $filename = null;
        } else {
            $filename = $this->_output ? $this->_output : (string) $this->getTmpFile();
        }
        if (!$command->execute($filename)) {
            $this->_error = $command->getError();
            if ($filename && !(file_exists($filename) && filesize($filename) !== 0 && $this->ignoreWarnings)) {
                return false;
            }
        }
        return true;
    }

    /**
     * Make sure, that only one file is present
     */
    protected function constrainSingleFile(): void
    {
        if ($this->getCommand()->getFileCount() > 1) {
            throw new \Exception('This operation can only process single files');
        }
    }

    /**
     * @return string the next handle in the series A, B, C, ... Z, AA, AB...
     */
    protected function nextHandle(): string
    {
        // N.B. Multi-character handles are only available in pdftk 1.45+

        $i = $this->_handle++;
        $char = 'A';
        while ($i-- > 0) {
            $char++;
        }

        return $char;
    }
}
